Repository navigation
fix(billing,payments,security): harden payment, billing and platform correctness - #1703
Conversation
…correctness
Closes #1683
Closes #1684
Closes #1685
Closes #1686
Crypto payment resilience (#1683)
- Add src/services/transactionConfirmation.ts: a single receipt-confirmation
path that requires chain-appropriate confirmation depth, rejects
receipt.status === 0 instead of reporting a revert as success, and enforces
its own timeout. ethers v5 ContractTransaction.wait() silently ignores a
timeout argument, so delegating the bound to it left every contract-based
payment able to hang and stall the retry queue queued behind it.
- Route Superfluid creation, Sablier creation/approval and ERC-20 approval
through that helper. Remove a duplicate PaymentMethodService import that
was a redeclaration error.
- rpcProvider: serialise access to the shared connection.url, so concurrent
calls (getBalancesAcrossChains fans out in parallel) can no longer send a
request to the wrong endpoint. Only transport failures now count against a
circuit; a JSON-RPC revert previously evicted healthy nodes. Require a
success threshold to close a half-open circuit.
- paymentMethodService: compare fractional spend caps with an exact
fixed-point comparison. BigNumber.from("12.5") throws, and the throw was
swallowed, so every fractional amount failed the cap check. Read token
decimals from the contract instead of assuming 6/18, and use resilient
providers for balance, gas and bytecode reads.
- transactionQueueStore: persist a broadcast marker before handing a payload
to the wallet, so a crash between submission and receipt cannot replay a
payment. Retire rather than re-send items whose outcome is unknown, add
exponential backoff, measure staleness from the last attempt, and remove the
unhandled rejections that could terminate the app.
- walletStore: read the real PaymentAttempt fields (status/attemptedAt).
Reading non-existent success/createdAt made every method score a 100%
success rate, leaving smart fallback ordering permanently inert. Also clear
isLoading when verification returns false.
Billing correctness (#1684)
- Add src/utils/invoiceTotals.ts and use it in createInvoice/updateInvoice.
updateInvoice dropped the discount, so editing any line on a discounted
invoice raised the amount payable; the discount was also never persisted.
Clamp discounts to the subtotal so a total can never go negative.
- Fix an infinite average invoice amount when invoices exist but none are paid
(Infinity is truthy, so `x / 0 || 0` returned Infinity).
- Add src/utils/billingCycleMath.ts as the one billing-cycle normaliser.
subscriptionStore omitted the annual cycle, so a 120/year plan reported 120
MRR instead of 10; analyticsService and analyticsSlice hard-coded 4.345
weeks/month while BILLING_CONVERSIONS uses 52/12.
- monthlyRevenueRecognitionJob: recognise only the increment since the last
run. Partial recognition was never marked complete, so the full elapsed
portion was re-recognised on every run, compounding daily. Track cumulative
per-entry progress and clamp deferred revenue at zero.
- groupBilling: price the actual invoiced period rather than "the last N days
relative to now", clamp ownerDiscount to 0-100, and apply tax.
- InvoiceGenerator: clamp the discount against the subtotal.
- meteringService: bill only usage inside the period being invoiced (it summed
all lifetime history while labelling it one month), fix the off-by-one
period end, and stop same-millisecond events from colliding into one
dropped-by-idempotency event.
- batchChargeService: fix the `RunHistory` field typo that made every batch
charge throw.
Platform security (#1686)
- pagination: remove the published CURSOR_HMAC_SECRET fallback. A known cursor
secret let anyone forge a cursor whose `field` is interpolated into SQL.
Require the secret in production, compare signatures in constant time, and
reject any sort/field identifier outside [A-Za-z0-9_].
- createApiServer: remove the published WEBHOOK_SIGNING_KEY fallback, which
allowed forging every outbound webhook signature. Fail closed in production.
- ApiKeyRotationService: hash keys with a keyed HMAC rather than unsalted
SHA-256, so a leaked key table cannot be attacked offline.
- Add backend/shared/security/outboundUrlGuard.ts and apply it to Zapier hook
registration, hook delivery and webhook replay, closing an SSRF path to
cloud metadata and RFC1918 hosts. Validate at both registration and send
time so already-stored targets are covered too.
- eventReplayWorker: sign with a real HMAC. The previous "sha256=" prefix
wrapped a 31-bit rolling hash, so replayed deliveries were forgeable.
- server.ts: resolve the rate-limit tier from the server-side record instead of
the client-supplied x-subscription-tier header, which granted a 100x
allowance to anyone who asked. Opt back in with TRUST_TIER_HEADER=true
behind a trusted proxy.
- server.ts: apply a 256 KiB default body cap. 31 of 32 routes read unbounded
bodies, and the HubSpot webhook accumulated chunks before signature checks.
- gateway/createApiServer: bound express.json() (10mb -> 256kb) and add
Referrer-Policy and X-DNS-Prefetch-Control.
- revalidate: compare the secret in constant time and allowlist revalidate
paths instead of passing caller input straight to res.revalidate().
Tests (#1685)
- Add suites for the new modules (transactionConfirmation, decimalAmount,
invoiceTotals, billingCycleMath, outboundUrlGuard) and extend the transaction
queue and RPC provider suites to cover the idempotency, backoff and
circuit-breaker behaviour. Update the group billing test to the explicit
period signature.
- Document the new required secrets in .env.example.
Deployment notes
- CURSOR_HMAC_SECRET, WEBHOOK_SIGNING_KEY and API_KEY_HASH_PEPPER must be set
to random values of at least 16 characters in production; startup fails
otherwise. Outside production an ephemeral value is generated with a warning.
- Rate-limit tiers are no longer self-declared by clients. Deployments behind
an authenticating proxy that sets x-subscription-tier should set
TRUST_TIER_HEADER=true and ensure the proxy strips the header from inbound
traffic.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 38302542 | Triggered | Zapier Webhook URL | 3a87a76 | backend/shared/security/tests/outboundUrlGuard.test.ts | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
…ture) (#1711) Follow-up to #1703, which is already merged. This clears the two review threads that were left unresolved there. ## Semgrep: `subtrackr.csrf.state-changing-route-without-csrf` (x2) The rule fired on these two lines in `backend/server/createApiServer.ts`: ```ts app.post('/api/v1/stripe/billing/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY })); app.post('/api/v1/paddle/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY })); ``` **These are false positives.** Both lines install a *body parser only* — they declare no handler and change no state. The rule matches `$ROUTER.post($PATH, $HANDLER)` and excepts only a middleware literally named `csrfProtection`, `requireAuth` or `verifyWebhookSignature`; it cannot follow the call into `createStripeBillingRouter` / `createPaddleRouter` in other files. The routes are genuinely protected, and they fail closed: | Condition | Response | | --- | --- | | Signing secret unconfigured (`verifier === null`) | `503` | | HMAC over raw bytes does not match | `401` | | Already-delivered event id | `200 { duplicate: true }`, never dispatched | The digest is compared with `crypto.timingSafeEqual` inside a 300 s replay window (`StripeWebhookVerifier.verify` / `PaddleWebhookVerifier.verify`). ### Why this is *not* fixed by adding middleware to the route The tempting fix — running `verify()` again at the app level — is a silent production regression. `verify()` calls `remember(eventId)` on success, so a second call within the same request returns `fresh: false`, and the router then answers `200 { duplicate: true }` **without ever dispatching the event**. Every Stripe/Paddle subscription lifecycle update would be dropped. So this lands as a documented suppression (inline `nosemgrep` plus an entry in `.semgrep/semgrep-suppressions.yml` with a 6-month expiry), with the double-verification trap written into the code comment so the next reviewer does not "fix" it the obvious wrong way. ## GitGuardian: hardcoded Zapier webhook URL `outboundUrlGuard.test.ts` asserted on: ```ts validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/') ``` A fixture, not a secret — but the `/hooks/catch/<n>/<n>/` path shape is indistinguishable from a live hook URL, so it raised a false incident. Replaced with a non-`/hooks/catch/` path on the same host. The assertion only needs "public host over https", and realistic Zapier-hostname coverage is kept. ## Verification - `node --experimental-strip-types --check` passes on both touched TS files. - The `nosemgrep` justification gate from `security-scan.yml` was reproduced locally and passes (every suppression line carries a `reason:`). - `npm run lint`, `npm run typecheck` and the Jest suites were **not** run — `node_modules` is not installed in this environment. CI will cover those.
|
Not a real secret — remediated in #1711. GitGuardian id 38302542 · File The finding is a test fixture, not a credential: const result = validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/');It is a URL passed to The reason it still fired is that the const result = validateOutboundUrl('https://hooks.zapier.com/example-endpoint');The assertion still needs only "public host over https", and keeping the real Thanks for the catch. |
…arately (#1880) ## Context PR #1703 was squash-merged into `main` as a single commit (`5afe754`), which bundled the fixes for four separate issues: - #1683 — crypto payment resilience - #1684 — subscription billing correctness - #1685 — automated test coverage - #1686 — platform security A single squash makes those four changes impossible to review, revert or merge independently: reverting one of them takes the other three with it, and none of them can be traced back to the issue that motivated it. This pull request is the first step of a revert-and-re-land split. It undoes the squash **mechanically** and changes nothing else, so that the four issues can be re-landed as four separate pull requests. ## What this does The revert is computed as `main - #1703`: for every file in the #1703 patch, the pre-#1703 content is folded back into the current file. Every change that was merged after #1703 is preserved — only the #1703 hunks are undone. The result is the code as it would have been if #1703 had never been merged. - 41 files touched by #1703 are reverted (40 of them differ from `main`). - 11 files that #1703 introduced are removed again. - `src/utils/invoiceTotals.ts` is kept as a reduced compatibility shim that still exports `isDiscountLineItem` and `resolveLineTotal`, because the itemised-discount work that was merged after #1703 imports them. The full totals module returns in the #1684 pull request. - `backend/billing/domain/InvoiceGenerator.ts` is unchanged: the discount clamping it gained is required by the later itemised-discount work, so it stays where it is. This commit carries no behaviour of its own — it only makes the four issues separable again. ## Merge order This pull request must be merged **first**. The four follow-up pull requests are stacked on top of it, each re-landing exactly one issue: 1. this PR — the revert 2. #1683 — crypto payment resilience 3. #1684 — subscription billing correctness 4. #1686 — platform security 5. #1685 — automated test coverage Each follow-up pull request is based on the branch of the pull request before it, so its diff contains exactly one issue's work and nothing else. After all five are merged, `main` is identical to its current state. ## Notes - The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords. - `main` already carries two unrelated syntax errors (`backend/server.ts` and `backend/services/batchChargeService.ts`) that predate this work. They are left untouched here.
## Context This is the first of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time. Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request ([the revert](Smartdevs17#1703)) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to: - #1683 — crypto payment resilience ## What this changes ### Transaction confirmation (`src/services/transactionConfirmation.ts`, new) Waiting for a transaction is now a first-class operation instead of a fire-and-forget `tx.wait()`: - chain-aware confirmation thresholds (`requiredConfirmations`), a default timeout, and distinct error types — `TransactionRevertedError`, `TransactionTimeoutError`, `TransactionInsufficientConfirmationsError` — so a caller can tell "the chain rejected it" from "we stopped waiting". - `isDefinitiveFailure()` separates failures that can never succeed on retry (revert, invalid params) from transport-level problems, which is what the queue below relies on. - `bestEffortTransactionHash()` recovers the hash when the wallet broadcast succeeded but the receipt was never observed. ### Decimal-safe amounts (`src/utils/decimalAmount.ts`, new) Token amounts were compared as JavaScript floats. `normalizeDecimal`, `compareDecimalAmounts` and `exceedsLimit` do the comparison on the decimal string instead, so an amount that is exactly at the limit is no longer reported as over the limit because of binary float drift. ### Resilient RPC provider (`src/services/rpcProvider.ts`) - The circuit breaker now has a `half-open` state with a success threshold, so a node that flaps is not immediately trusted again. - Only transport-level errors count towards the failure threshold. A legitimate JSON-RPC failure (revert, insufficient funds) means the node answered correctly and must not evict a healthy endpoint. - Endpoint switching is serialised through a swap chain. The base ethers provider talks to a single mutable `connection.url`, so two concurrent calls (e.g. `getBalancesAcrossChains`, which fans out in parallel) previously interleaved and one of them was sent to a node on a different chain. ### Payment method limits (`src/services/paymentMethodService.ts`) - The token's own `decimals()` is read instead of assuming "USDC = 6, everything else = 18", which under- or over-reported balances and let a charge through that could not settle. - `maxSpendPerInterval` is enforced with `exceedsLimit()` instead of a float comparison. - Balance reads go through the resilient provider. ### Wallet operations (`src/services/walletService.ts`) Stream creation and token approvals now go through `confirmTransaction()` and fail loudly when the chain does not confirm them, instead of resolving optimistically. ### Transaction queue (`src/store/transactionQueueStore.ts`, `src/store/walletStore.ts`) - A `broadcastedAt` marker is persisted **before** the transaction is handed to the wallet. A broadcast can succeed on-chain even when the app dies before the receipt is observed, so the next queue pass can tell "never sent" apart from "sent, outcome unknown" and refuse to re-broadcast the latter — which would otherwise charge the user twice. - Failed attempts are retried with exponential backoff (5 s, doubling, capped at 5 min) and only when the failure is not definitive, so a reverted transaction is not retried forever. ## Merge order This branch is stacked on the revert pull request and must be merged after it. The remaining pull requests re-land #1684, #1686 and #1685 in that order. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
## Context This is the second of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time. Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to: - #1684 — subscription billing correctness ## What this changes ### One place that computes invoice totals (`src/utils/invoiceTotals.ts`) `createInvoice` and `updateInvoice` each rolled their own arithmetic and the two did not agree — `createInvoice` subtracted the discount, `updateInvoice` did not. Editing one line on a discounted invoice therefore silently increased the amount payable, and because the discount was never persisted on the record there was nothing left to subtract on the second pass. `calculateInvoiceTotals()` is now the single source of truth: - every component is rounded to minor units (half-up, no binary float drift); - discount line items (`kind: 'discount'`) are kept out of the subtotal and summed into `discount` instead, so an itemised discount is subtracted exactly once; - the discount is clamped to the subtotal. Without the clamp a discount larger than the invoice produced a negative tax base and a negative total, which downstream reconciliation reads as a credit owed rather than an over-discounted invoice. ### Invoice service (`src/services/invoiceService.ts`) Both the create and the update path go through the shared totals function, and the discount is persisted on the record so a second pass subtracts the same amount. ### Billing cycle maths (`src/utils/billingCycleMath.ts`, new) `monthlyEquivalent`, `yearlyEquivalent`, `isAnnualCycle` and `normalizeBillingCycle` replace the ad-hoc cycle conversions that were scattered across the store and the analytics service, which disagreed about what a weekly or yearly cycle was worth. ### Revenue recognition (`backend/billing/jobs/monthlyRevenueRecognitionJob.ts`, `backend/billing/jobs/index.ts`) The job now tracks cumulative per-entry recognition progress (`recordEntryProgress`, `recognisedToDate`). Previously each run re-recognised the whole elapsed portion of an entry, so recognised revenue was inflated on every run and the deferred balance grew without bound. ### Metering and group billing (`backend/services/billing/meteringService.ts`, `groupBilling.ts`, `interfaces.ts`) - Metered billing aggregates over the requested window instead of the last N days from "now", so a period that ended long ago no longer picks up today's usage. - `aggregateCharges(group, from, to)` takes an explicit window. - Amounts are rounded to minor units on the way out. ### Analytics (`src/services/analyticsService.ts`, `src/store/slices/analyticsSlice.ts`, `src/store/subscriptionStore.ts`, `backend/services/analytics/subscriptionAnalyticsService.ts`) Monthly and yearly equivalents now come from the shared billing-cycle helpers, so the dashboard, the store and the backend report the same numbers. ### Batch charges (`backend/services/batchChargeService.ts`) Uses the shared rounding helper. ### Test (`src/services/__tests__/groupBilling.test.ts`) Updated for the explicit-window `aggregateCharges` signature. ## Merge order This branch is stacked on the #1683 pull request and must be merged after it. The remaining pull requests re-land #1686 and #1685 in that order. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
## Context This is the third of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time. Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to: - #1686 — platform security ## What this changes ### Rate-limit tier cannot be spoofed (`backend/server.ts`, `backend/services/shared/rateLimitingService.ts`) The caller's tier was read from the client-supplied `x-subscription-tier` header verbatim, so any caller presenting a key could send `X-Subscription-Tier: enterprise` and receive the 10,000 req/hr allowance instead of the FREE tier's 100 req/hr — a 100× rate-limit bypass requiring no privilege at all. The tier now comes from the server-side record for that key (`rateLimitingService.getTierForApiKey`). The header is honoured only when the explicit `TRUST_TIER_HEADER=true` opt-in is configured, which suits deployments where an authenticating gateway sits in front of this process and strips the header from inbound requests. ### Outbound URL guard (`backend/shared/security/outboundUrlGuard.ts`, new) `validateOutboundUrl()` rejects URLs that resolve to private, loopback, link-local or otherwise non-routable addresses, including DNS names that resolve to them. `isPrivateHostname()` and `assertSafeIdentifier()` are the building blocks. Used by: - `backend/integrations/zapier/ZapierIntegrationService.ts` and `zapierRouter.ts` — the Zapier integration fetches a user-supplied URL, which was a server-side request forgery vector. - `backend/services/webhook/eventReplayWorker.ts` — the replay worker re-delivers to a stored webhook target. ### Cursor pagination (`backend/services/shared/pagination.ts`, new) `encodeCursor` / `decodeCursor` sign the cursor payload with a secret, so a client cannot craft one; `buildCursorClause` and `buildPage` provide keyset pagination, and `parseFieldSelection` / `selectFields` let a caller restrict the returned columns. ### API key rotation (`backend/services/auth/domain/ApiKeyRotationService.ts`) Key rotation records the previous key's state so it can be revoked and audited. ### Webhook body limits and raw payload handling (`backend/server/createApiServer.ts`) The two provider webhook registrations now cap the raw body size (`limit: MAX_JSON_BODY`), so a webhook POST cannot be used to exhaust memory. The registrations remain body-parser-only — no handler, no state change — and the HMAC verification still happens inside the Stripe and Paddle routers, which fail closed with 503 when unconfigured and 401 on a bad signature. ### Revalidation allow-list (`developer-portal/pages/api/revalidate.ts`) `path` is caller-supplied and goes straight into `res.revalidate()`, so an arbitrary value let a holder of the secret force revalidation of unrelated routes. Only the documented set of paths is accepted now, and the secret is compared in constant time. ### Gateway (`backend/gateway/index.ts`) and configuration (`.env.example`) The new toggles (`TRUST_TIER_HEADER`, the pagination cursor secret, the webhook body limit) are documented in `.env.example`. ## Merge order This branch is stacked on the #1684 pull request and must be merged after it. The last pull request re-lands #1685. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
## Context This is the last of four pull requests that re-land the contents of the squash-merged PR Smartdevs17#1703 one issue at a time. Smartdevs17#1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of Smartdevs17#1703 that belongs to: - #1685 — automated test coverage ## What this changes Seven new test suites that cover the behaviour introduced by the other three issues: | Suite | Covers | | --- | --- | | `src/utils/__tests__/decimalAmount.test.ts` | decimal-safe normalisation and comparison, including values exactly at a limit | | `src/utils/__tests__/billingCycleMath.test.ts` | `monthlyEquivalent` / `yearlyEquivalent` for weekly, monthly and yearly cycles | | `src/utils/__tests__/invoiceTotals.test.ts` | `calculateInvoiceTotals`: rounding, an itemised discount subtracted exactly once, and the discount clamp | | `src/services/__tests__/transactionConfirmation.test.ts` | confirmation thresholds, timeouts, reverts and the definitive-failure classification | | `src/services/__tests__/rpcProvider.test.ts` | circuit-breaker transitions, the half-open success threshold and endpoint failover | | `src/store/_tests_/transactionQueueStore.test.ts` | the broadcast marker, exponential backoff and the refusal to re-broadcast an unknown-outcome transaction | | `backend/shared/security/__tests__/outboundUrlGuard.test.ts` | rejection of private, loopback and link-local outbound targets | ## Merge order This branch is stacked on the #1686 pull request and must be merged **last**: the suites exercise the modules added by the #1683, #1684 and #1686 pull requests, so they only compile and pass once those are in place. Merging this pull request completes the split. After all five pull requests are merged, `main` is identical to its current state. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue 1683 only. The remainder of the squash is re-landed by the other branches of the split, so this diff contains this issue's work exclusively.
## Context This is the first of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time. #1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request ([the revert](#1703)) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to: - #1683 — crypto payment resilience ## What this changes ### Transaction confirmation (`src/services/transactionConfirmation.ts`, new) Waiting for a transaction is now a first-class operation instead of a fire-and-forget `tx.wait()`: - chain-aware confirmation thresholds (`requiredConfirmations`), a default timeout, and distinct error types — `TransactionRevertedError`, `TransactionTimeoutError`, `TransactionInsufficientConfirmationsError` — so a caller can tell "the chain rejected it" from "we stopped waiting". - `isDefinitiveFailure()` separates failures that can never succeed on retry (revert, invalid params) from transport-level problems, which is what the queue below relies on. - `bestEffortTransactionHash()` recovers the hash when the wallet broadcast succeeded but the receipt was never observed. ### Decimal-safe amounts (`src/utils/decimalAmount.ts`, new) Token amounts were compared as JavaScript floats. `normalizeDecimal`, `compareDecimalAmounts` and `exceedsLimit` do the comparison on the decimal string instead, so an amount that is exactly at the limit is no longer reported as over the limit because of binary float drift. ### Resilient RPC provider (`src/services/rpcProvider.ts`) - The circuit breaker now has a `half-open` state with a success threshold, so a node that flaps is not immediately trusted again. - Only transport-level errors count towards the failure threshold. A legitimate JSON-RPC failure (revert, insufficient funds) means the node answered correctly and must not evict a healthy endpoint. - Endpoint switching is serialised through a swap chain. The base ethers provider talks to a single mutable `connection.url`, so two concurrent calls (e.g. `getBalancesAcrossChains`, which fans out in parallel) previously interleaved and one of them was sent to a node on a different chain. ### Payment method limits (`src/services/paymentMethodService.ts`) - The token's own `decimals()` is read instead of assuming "USDC = 6, everything else = 18", which under- or over-reported balances and let a charge through that could not settle. - `maxSpendPerInterval` is enforced with `exceedsLimit()` instead of a float comparison. - Balance reads go through the resilient provider. ### Wallet operations (`src/services/walletService.ts`) Stream creation and token approvals now go through `confirmTransaction()` and fail loudly when the chain does not confirm them, instead of resolving optimistically. ### Transaction queue (`src/store/transactionQueueStore.ts`, `src/store/walletStore.ts`) - A `broadcastedAt` marker is persisted **before** the transaction is handed to the wallet. A broadcast can succeed on-chain even when the app dies before the receipt is observed, so the next queue pass can tell "never sent" apart from "sent, outcome unknown" and refuse to re-broadcast the latter — which would otherwise charge the user twice. - Failed attempts are retried with exponential backoff (5 s, doubling, capped at 5 min) and only when the failure is not definitive, so a reverted transaction is not retried forever. ## Merge order This is the second of five pull requests in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) was merged, so its diff contains the #1683 work and nothing else. The remaining three pull requests (#1684, #1686, #1685) are opened on top of this one, in that order, and must be merged after it. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue 1684 only. The remainder of the squash is re-landed by the other branches of the split, so this diff contains this issue's work exclusively.
## Context This is the second of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time. #1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to: - #1684 — subscription billing correctness ## What this changes ### One place that computes invoice totals (`src/utils/invoiceTotals.ts`) `createInvoice` and `updateInvoice` each rolled their own arithmetic and the two did not agree — `createInvoice` subtracted the discount, `updateInvoice` did not. Editing one line on a discounted invoice therefore silently increased the amount payable, and because the discount was never persisted on the record there was nothing left to subtract on the second pass. `calculateInvoiceTotals()` is now the single source of truth: - every component is rounded to minor units (half-up, no binary float drift); - discount line items (`kind: 'discount'`) are kept out of the subtotal and summed into `discount` instead, so an itemised discount is subtracted exactly once; - the discount is clamped to the subtotal. Without the clamp a discount larger than the invoice produced a negative tax base and a negative total, which downstream reconciliation reads as a credit owed rather than an over-discounted invoice. ### Invoice service (`src/services/invoiceService.ts`) Both the create and the update path go through the shared totals function, and the discount is persisted on the record so a second pass subtracts the same amount. ### Billing cycle maths (`src/utils/billingCycleMath.ts`, new) `monthlyEquivalent`, `yearlyEquivalent`, `isAnnualCycle` and `normalizeBillingCycle` replace the ad-hoc cycle conversions that were scattered across the store and the analytics service, which disagreed about what a weekly or yearly cycle was worth. ### Revenue recognition (`backend/billing/jobs/monthlyRevenueRecognitionJob.ts`, `backend/billing/jobs/index.ts`) The job now tracks cumulative per-entry recognition progress (`recordEntryProgress`, `recognisedToDate`). Previously each run re-recognised the whole elapsed portion of an entry, so recognised revenue was inflated on every run and the deferred balance grew without bound. ### Metering and group billing (`backend/services/billing/meteringService.ts`, `groupBilling.ts`, `interfaces.ts`) - Metered billing aggregates over the requested window instead of the last N days from "now", so a period that ended long ago no longer picks up today's usage. - `aggregateCharges(group, from, to)` takes an explicit window. - Amounts are rounded to minor units on the way out. ### Analytics (`src/services/analyticsService.ts`, `src/store/slices/analyticsSlice.ts`, `src/store/subscriptionStore.ts`, `backend/services/analytics/subscriptionAnalyticsService.ts`) Monthly and yearly equivalents now come from the shared billing-cycle helpers, so the dashboard, the store and the backend report the same numbers. ### Batch charges (`backend/services/batchChargeService.ts`) Uses the shared rounding helper. ### Test (`src/services/__tests__/groupBilling.test.ts`) Updated for the explicit-window `aggregateCharges` signature. ## Merge order This is the third of five pull requests in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) and the #1683 pull request ([#1881](#1881)) were merged, so its diff contains the #1684 work and nothing else. The remaining two pull requests (#1686, #1685) are opened on top of this one, in that order, and must be merged after it. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue 1686 only. The remainder of the squash is re-landed by the other branches of the split, so this diff contains this issue's work exclusively.
## Context This is the third of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time. #1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to: - #1686 — platform security ## What this changes ### Rate-limit tier cannot be spoofed (`backend/server.ts`, `backend/services/shared/rateLimitingService.ts`) The caller's tier was read from the client-supplied `x-subscription-tier` header verbatim, so any caller presenting a key could send `X-Subscription-Tier: enterprise` and receive the 10,000 req/hr allowance instead of the FREE tier's 100 req/hr — a 100× rate-limit bypass requiring no privilege at all. The tier now comes from the server-side record for that key (`rateLimitingService.getTierForApiKey`). The header is honoured only when the explicit `TRUST_TIER_HEADER=true` opt-in is configured, which suits deployments where an authenticating gateway sits in front of this process and strips the header from inbound requests. ### Outbound URL guard (`backend/shared/security/outboundUrlGuard.ts`, new) `validateOutboundUrl()` rejects URLs that resolve to private, loopback, link-local or otherwise non-routable addresses, including DNS names that resolve to them. `isPrivateHostname()` and `assertSafeIdentifier()` are the building blocks. Used by: - `backend/integrations/zapier/ZapierIntegrationService.ts` and `zapierRouter.ts` — the Zapier integration fetches a user-supplied URL, which was a server-side request forgery vector. - `backend/services/webhook/eventReplayWorker.ts` — the replay worker re-delivers to a stored webhook target. ### Cursor pagination (`backend/services/shared/pagination.ts`, new) `encodeCursor` / `decodeCursor` sign the cursor payload with a secret, so a client cannot craft one; `buildCursorClause` and `buildPage` provide keyset pagination, and `parseFieldSelection` / `selectFields` let a caller restrict the returned columns. ### API key rotation (`backend/services/auth/domain/ApiKeyRotationService.ts`) Key rotation records the previous key's state so it can be revoked and audited. ### Webhook body limits and raw payload handling (`backend/server/createApiServer.ts`) The two provider webhook registrations now cap the raw body size (`limit: MAX_JSON_BODY`), so a webhook POST cannot be used to exhaust memory. The registrations remain body-parser-only — no handler, no state change — and the HMAC verification still happens inside the Stripe and Paddle routers, which fail closed with 503 when unconfigured and 401 on a bad signature. ### Revalidation allow-list (`developer-portal/pages/api/revalidate.ts`) `path` is caller-supplied and goes straight into `res.revalidate()`, so an arbitrary value let a holder of the secret force revalidation of unrelated routes. Only the documented set of paths is accepted now, and the secret is compared in constant time. ### Gateway (`backend/gateway/index.ts`) and configuration (`.env.example`) The new toggles (`TRUST_TIER_HEADER`, the pagination cursor secret, the webhook body limit) are documented in `.env.example`. ## Merge order This is the fourth of five pull requests in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) and the #1683 ([#1881](#1881)) and #1684 ([#1882](#1882)) pull requests were merged, so its diff contains the #1686 work and nothing else. The last pull request (#1685) is opened on top of this one and must be merged after it. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
Re-lands the part of the reverted Smartdevs17#1703 squash that belongs to issue 1685 only. The remainder of the squash is re-landed by the other branches of the split, so this diff contains this issue's work exclusively.
## Context This is the last of four pull requests that re-land the contents of the squash-merged PR #1703 one issue at a time. #1703 was merged as a single commit (`5afe754`) covering four unrelated issues. The companion pull request (the revert) undoes that squash so each issue can be merged on its own. This pull request restores **only** the part of #1703 that belongs to: - #1685 — automated test coverage ## What this changes Seven new test suites that cover the behaviour introduced by the other three issues: | Suite | Covers | | --- | --- | | `src/utils/__tests__/decimalAmount.test.ts` | decimal-safe normalisation and comparison, including values exactly at a limit | | `src/utils/__tests__/billingCycleMath.test.ts` | `monthlyEquivalent` / `yearlyEquivalent` for weekly, monthly and yearly cycles | | `src/utils/__tests__/invoiceTotals.test.ts` | `calculateInvoiceTotals`: rounding, an itemised discount subtracted exactly once, and the discount clamp | | `src/services/__tests__/transactionConfirmation.test.ts` | confirmation thresholds, timeouts, reverts and the definitive-failure classification | | `src/services/__tests__/rpcProvider.test.ts` | circuit-breaker transitions, the half-open success threshold and endpoint failover | | `src/store/_tests_/transactionQueueStore.test.ts` | the broadcast marker, exponential backoff and the refusal to re-broadcast an unknown-outcome transaction | | `backend/shared/security/__tests__/outboundUrlGuard.test.ts` | rejection of private, loopback and link-local outbound targets | ## Merge order This is the fifth and last pull request in the split of #1703. It is based on `main` **after** the revert pull request ([#1880](#1880)) and the #1683 ([#1881](#1881)), #1684 ([#1882](#1882)) and #1686 ([#1883](#1883)) pull requests were merged, so its diff contains the #1685 work and nothing else. The suites exercise the modules added by the #1683, #1684 and #1686 pull requests, so this pull request has to come last. Merging it completes the split. After all five pull requests are merged, `main` is identical to its state before the split. The issues referenced above (#1683–#1686) have been deleted from the tracker, so they are referenced as plain text rather than with closing keywords.
Closes #1683
Closes #1684
Closes #1685
Closes #1686
Summary
One focused hardening pass across the four Stellar Wave iteration-10 issues. Each
section below names the concrete defect fixed, because in every case the
original code looked correct at a glance and failed in a way that silently
produced wrong money or unsafe behaviour.
#1683 — Improve crypto payment resilience
Reverted transactions were reported as successful. Every on-chain path called
tx.wait()with no arguments and then only asserted that a hash came back. Areceipt with
status === 0means mined but reverted, so a failed payment wassurfaced to the user as confirmed.
New
src/services/transactionConfirmation.tscentralises three concerns and isnow used by Superfluid creation, Sablier creation, Sablier approval and ERC-20
approval:
receipt.status === 0;ContractTransaction.wait(confirmations)does not forward a timeout argumentat all, so delegating the bound to it left every contract-based payment able
to hang forever — and because
processQueueis sequential, one stucktransaction stalled the whole offline retry queue behind it.
The RPC provider could send a request to the wrong endpoint. The base ethers
provider has one mutable
connection.url, and the fallback logic swapped it percall.
getBalancesAcrossChainsfans out in parallel, so concurrent callsinterleaved and requests could be routed to a node on a different chain. Access
is now serialised.
Healthy nodes were being evicted from the circuit. Every error counted as a
failure, including legitimate JSON-RPC errors such as reverts and insufficient
funds — a working node answering correctly was treated as unreachable.
isTransportFailurenow separates transport problems from application-levelanswers. Half-open circuits also required a single success to close; they now
require a threshold, matching the documented behaviour.
Fractional amounts could never pass the spend cap. The check was
ethers.BigNumber.from(amount).gt(limit), andBigNumber.from("12.5")throws.The throw was swallowed by the surrounding
catch, so every fractional amountwas recorded as a generic failure. Replaced with exact fixed-point comparison
(
src/utils/decimalAmount.ts) — notparseFloat, which would reintroduce floaterror.
Balances used hard-coded decimals.
checkBalanceassumedUSDC ? 6 : 18,under- or over-reporting any other ERC-20 and triggering spurious fallbacks.
Decimals are now read from the contract. All four raw
JsonRpcProviderconstructions also bypassed the timeout/circuit-breaker/fallback layer entirely.
A crash between broadcast and receipt could charge the user twice. The queue
removed an entry only after execution, so an app crash mid-flight left the
item pending and the next pass re-broadcast it. A
broadcastedAtmarker is nowpersisted before the payload reaches the wallet. Items whose outcome is
genuinely unknown are retired with a "needs review" notification rather than
re-sent, and the marker is only cleared for failures that prove nothing was
submitted (wallet rejection). Also added exponential backoff, staleness measured
from the last attempt rather than creation, reference-counted connectivity
listeners, and removal of unhandled rejections that can terminate the app.
Smart fallback ordering was inert.
walletStorereadattempt.successandattempt.createdAt, neither of which exists onPaymentAttempt(it hasstatusandattemptedAt). Every field resolved toundefined, the monitor'slook-back window discarded all attempts, and every method scored a 100% success
rate. Also fixed
verifyPaymentMethodleavingisLoadingstuck whenverification returned
false.#1684 — Strengthen subscription billing correctness
Editing a line on a discounted invoice increased the amount payable.
createInvoicesubtracted the discount but never persisted it, andupdateInvoicerecomputedsubtotal + taxwith no discount at all. Newsrc/utils/invoiceTotals.tsis the single arithmetic path for both, clamps thediscount to the subtotal so a total can never go negative, and rounds each
component to minor units.
Average invoice amount was
Infinity.totalRevenue / paidInvoices || 0returns
Infinitywhen no invoice is paid, becauseInfinityis truthy.MRR was inflated 12× for annual plans.
subscriptionStorecompared rawstrings and omitted
annualentirely, so a 120/year plan reported 120 MRRinstead of 10 — and this is the store that populates
statsfor the app.analyticsServiceandanalyticsSlicehad the same omission plus a hard-coded4.345weeks/month whileBILLING_CONVERSIONSuses52/12. All four now usesrc/utils/billingCycleMath.ts.Revenue was re-recognised in full on every run. In
monthlyRevenueRecognitionJob, a'partial'recognition was never markedcomplete, so the next daily run re-recognised the entire elapsed portion,
compounding daily. The job now recognises only the increment since the last run,
tracking cumulative per-entry progress via a new optional repository method.
When that method is absent it degrades to recognising each entry once at full
amount — slower, but never over-counting. Deferred revenue is clamped at zero.
Group invoices priced the wrong window.
generateInvoicederivedperiodDaysfrom the period but
aggregateChargescomputed its cutoff fromDate.now(), soa historical invoice priced a different set of charges than the period it claims.
aggregateChargesnow takes an explicit(periodStart, periodEnd).ownerDiscountwas unclamped (a negative value increased the bill, >100 gave anegative payable amount) and tax was hard-coded to 0.
Usage billing summed all history but labelled it one month.
calculateUsageBillingaggregated the unbounded history array while reportingthe current calendar month, over-billing every account with prior history each
cycle. Period end was also
new Date(y, m+1, 0)— midnight starting the lastday, excluding the final day. Additionally, auto idempotency keys built purely
from the event timestamp collided for same-millisecond events, and the second was
silently dropped and never rated.
Also fixed a
RunHistoryfield typo inbatchChargeServicethat made everybatch charge throw on
recordRun.#1686 — Harden platform security
Forgeable pagination cursors → SQL identifier injection.
pagination.tsfellback to the literal
'subtrackr-cursor-secret', published in the repo. Cursorscarry a
fieldthat is interpolated intoORDER BY/WHERE, so a known secretlet anyone forge one. The secret is now required in production, signatures are
compared with
timingSafeEqual, and any sort/field identifier outside[A-Za-z0-9_]is rejected.Forgeable webhook signatures.
createApiServerfell back to'default-dev-key-change-in-prod'for the key that signs every outboundwebhook. Removed; production startup now fails instead.
Weak webhook signature scheme.
eventReplayWorkeremittedsha256=<31-bit rolling hash>— labelled SHA-256, not SHA-256. Replaced with areal keyed HMAC.
API keys hashed with unsalted SHA-256. Now a keyed HMAC, so a leaked key
table cannot be attacked offline. Remains deterministic so lookups still work.
SSRF. Zapier hook
targetUrlwas stored verbatim and later fetchedserver-side, reaching cloud metadata (
169.254.169.254), loopback and RFC1918hosts. New
backend/shared/security/outboundUrlGuard.tsvalidates scheme,embedded credentials, and private/loopback/link-local/CGNAT/malformed-IP-literal
hosts. Applied at registration and delivery, so already-stored targets are
covered too.
100× rate-limit bypass. The tier came from the client-supplied
x-subscription-tierheader, so anyone could requestenterpriseand get10,000 req/hr instead of 100. Now resolved from the server-side record, with an
explicit
TRUST_TIER_HEADER=trueopt-in for trusted-proxy deployments.Unbounded request bodies.
maxByteswas optional and 31 of 32 routes omittedit, so any route could buffer the whole body into memory before parsing; the
HubSpot webhook accumulated chunks before its signature check. A 256 KiB default
now applies everywhere. The gateway's blanket
10mbJSON limit is reduced to256kb.Timing-unsafe secret comparison + unvalidated revalidate path. The
REVALIDATE_SECRETcheck short-circuited on the first differing byte, andpathwent straight intores.revalidate(). Now constant-time andallowlisted.
#1685 — Expand automated test coverage
New suites for the new modules (
transactionConfirmation,decimalAmount,invoiceTotals,billingCycleMath,outboundUrlGuard) plus extensions to thetransaction-queue and RPC-provider suites covering idempotency, backoff and
circuit-breaker behaviour. Each new test names the regression it locks in.
The existing
groupBillingtest was updated to the explicit period signature; itsprevious assertion encoded the
Date.now()-relative window bug.Deployment notes
Three secrets are now required in production and the process fails to start
without them:
CURSOR_HMAC_SECRETWEBHOOK_SIGNING_KEYAPI_KEY_HASH_PEPPERAll need random values of at least 16 characters. Outside production an
ephemeral per-process value is generated with a warning — which means cursors
and hashed keys do not survive a restart in dev. Documented in
.env.example.Behaviour change: rate-limit tiers are no longer self-declared by clients.
A deployment behind an authenticating proxy that sets
x-subscription-tiershould set
TRUST_TIER_HEADER=trueand ensure the proxy strips that header frominbound traffic. Without a server-side tier record, callers resolve to
FREE.Verification
npm run lint,npx tsc --noEmitandnpm run format:checkwere not runlocally —
node_modulesis not installed in this environment and installingdependencies was out of scope for this task. The changed
src/files weremanually checked against the configured Prettier rules (printWidth 100,
trailingComma: es5, single quotes) and for unused imports, sinceprettier/prettieris configured as an ESLint error.